#!/usr/bin/env python3

# Copyright (c) 2020-2021 Fpemud <fpemud@sina.com>
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.

import os
import re
import glob
import shutil
import inspect
import fnmatch
import asyncio
import filecmp
import subprocess
import configparser
import strict_hwcfg


class Util:

    @staticmethod
    async def subprocess_exec_check_call(*kargs, **kwargs):
        proc = await asyncio.create_subprocess_exec(*kargs, **kwargs)
        await proc.wait()
        if proc.returncode != 0:
            raise subprocess.CalledProcessError(proc.returncode, kargs)

    @staticmethod
    async def subprocess_shell_check_call(*kargs, **kwargs):
        assert len(kargs) == 1
        proc = await asyncio.create_subprocess_shell(*kargs, **kwargs)
        await proc.wait()
        if proc.returncode != 0:
            raise subprocess.CalledProcessError(proc.returncode, *kargs)

    @staticmethod
    def addNewItemToSet(dst_set, new_item):
        assert new_item not in dst_set
        dst_set.add(new_item)

    @staticmethod
    def hwcfgGeJoystickComponent(machine):
        ret = []
        for c in machine.get_components():
            if c.get_type() in [strict_hwcfg.ComponentType.FLIGHT_STICK, strict_hwcfg.ComponentType.STEERING_WHEEL, strict_hwcfg.ComponentType.GAMEPAD]:
                ret.append(c)
        return ret

    @staticmethod
    def writeIfNeccessary(path, buf):
        if isinstance(buf, str):
            postFix = ""
        elif isinstance(buf, bytes):
            postFix = "b"
        else:
            assert False

        with open(path, "r" + postFix) as f:
            if buf == f.read():
                return

        with open(path, "w" + postFix) as f:
            f.write(buf)

    @staticmethod
    def enumFindByValue(enumClass, value):
        for member in enumClass:
            if member.value == value:
                return member
        assert False

    @staticmethod
    def listGetOnlyItem(listObj):
        assert len(listObj) == 1
        return listObj[0]

    @staticmethod
    def isArchCompatible(curArch, targetArch):
        # targetArch and curArch should be the return value of platform.machine()
        if targetArch == curArch:
            return True
        if targetArch == "i386" and curArch == "x86_64":
            return True
        return False

    def dictMGet(d, *kargs, **kwargs):
        assert len(kargs) > 0
        assert len(kwargs) == 1 and "fallback" in kwargs

        for k in kargs[:-1]:
            if k not in d:
                return kwargs["fallback"]
            d = d[k]

        return d.get(kargs[-1], kwargs["fallback"])

    def expandList(listFull, listWithWildcard, wildcard, elementClass):
        assert len(listWithWildcard) > 0

        def __getCur(i):
            ret = listWithWildcard[i]
            assert ret == wildcard or isinstance(ret, elementClass)
            return ret

        def __getNext(i):
            if i < len(listWithWildcard) - 1:
                ret = listWithWildcard[i + 1]
                if listWithWildcard[i] == wildcard:
                    assert isinstance(ret, elementClass)
                else:
                    assert ret == "*" or isinstance(ret, elementClass)
                return ret
            else:
                return None

        # wildcard expand
        filledVerList = []
        i, cv, nv = 0, __getCur(0), __getNext(0)
        for v in listFull:
            if cv == wildcard:
                if v != nv:
                    filledVerList.append(v)
                else:
                    i, cv, nv = i + 1, nv, __getNext(i + 1)
            else:
                if v != cv:
                    pass
                else:
                    filledVerList.append(v)
                    i, cv, nv = i + 1, nv, __getNext(i + 1)

        return filledVerList

    @staticmethod
    def forceDelete(path):
        if os.path.islink(path):
            os.remove(path)
        elif os.path.isfile(path):
            os.remove(path)
        elif os.path.isdir(path):
            shutil.rmtree(path)
        elif os.path.lexists(path):
            os.remove(path)             # other type of file, such as device node
        else:
            pass                        # path does not exist, do nothing

    @staticmethod
    def forceSymlink(srcPath, dstPath):
        if not (os.path.islink(dstPath) and os.readlink(dstPath) == os.path.abspath(srcPath)):
            Util.forceDelete(dstPath)
            os.symlink(srcPath, dstPath)

    @staticmethod
    def forceCopyFile(srcPath, dstPath):
        assert os.path.isfile(srcPath)
        if os.path.islink(dstPath) or not os.path.isfile(dstPath) or not filecmp.cmp(srcPath, dstPath):
            Util.forceDelete(dstPath)
            shutil.copy(srcPath, dstPath)

    @staticmethod
    def forceMakeDir(path):
        if not os.path.exists(path):
            os.mkdir(path)
        elif os.path.islink(path) or not os.path.isdir(path):
            Util.forceDelete(path)
            os.mkdir(path)
        else:
            pass

    @staticmethod
    def forceMakeEmptyDir(path):
        if not os.path.exists(path):
            os.mkdir(path)
        elif os.path.islink(path) or not os.path.isdir(path):
            Util.forceDelete(path)
            os.mkdir(path)
        else:
            for fn in os.listdir(path):
                Util.forceDelete(os.path.join(path, fn))

    @staticmethod
    def forceCopy(srcPath, dstPath):
        Util.forceDelete(dstPath)
        if os.path.isfile(srcPath):
            shutil.copy(srcPath, dstPath)
        elif os.path.isdir(srcPath):
            shutil.copytree(srcPath, dstPath)
        else:
            assert False

    @staticmethod
    def glob(*kargs, **kwargs):
        ret = glob.glob(*kargs, **kwargs)
        ret = [x if x == "/" else x.rstrip("/") for x in ret]       # some function (for example os.path.islink) thinks "a" and "a/" are different, we always use "a"
        return ret

    @staticmethod
    def encodeUnicode(string):
        # FIXME
        return string

    @staticmethod
    def decodeUnicode(string):
        # There may be a r"\\" in front of r"\x", so replace the r"\\" to r"\x005c"
        # to avoid missing matches. Example: r"C:\\users\\x1234\\\x0041\x0042CD".
        # Note the difference between r"\\x1234", r"\\\x0041" and r"\x0042".
        # It should be r"C:\users\x1234\ABCD" after decoding.
        chunks = re.split(r"\\x", string.replace(r"\\", r"\x005c"))
        out = chunks.pop(0).encode().decode("unicode_escape")
        for chunk in chunks:
            # We have seen file with unicode characters escaped on 1 byte (\xfa),
            # 1.5 bytes (\x444) and 2 bytes (\x00ed). So we try 0 padding, 1 and 2
            # (python wants its escaped sequence to be exactly on 4 characters).
            # The exception let us know if it worked or not
            for i in [0, 1, 2]:
                try:
                    out += ("\\u{}{}".format("0" * i, chunk).encode().decode("unicode_escape"))
                    break
                except UnicodeDecodeError:
                    pass
        return out


class DynObject:
    pass


class StraitObject:

    def __init__(self, strait_clazz):
        self._straitClazz = strait_clazz

        # self._inheritedStraitClassList does not take part in self._batchCall() and self.__getattr__()
        # FIXME: should check if cls has conflict methods etc.
        self._inheritedStraitClassList = []
        for cls in self.__class__.__bases__:
            if issubclass(cls, self._straitClazz):
                self._inheritedStraitClassList.append(cls)

        self._straitObjList = []

        self._straitMethodDict = {}

    def has_strait(self, clazz):
        return self.get_strait(clazz) is not None

    def get_strait(self, clazz):
        for cls in self._inheritedStraitClassList:
            if cls == clazz:
                return self
        for obj in self._straitObjList:
            if isinstance(obj, clazz):
                return obj
        return None

    def _addStrait(self, obj):
        assert isinstance(obj, self._straitClazz)

        assert not self.has_strait(obj.__class__)
        self._straitObjList.append(obj)

        # get method and inner class
        for name in dir(obj):
            if name.startswith("_"):
                continue
            m = getattr(obj, name)
            if not callable(m) and not isinstance(m, type):
                continue
            assert name not in self._straitMethodDict
            self._straitMethodDict[name] = obj

        # get @property
        for name, _ in inspect.getmembers(obj.__class__, lambda x: isinstance(x, property)):
            if name.startswith("_"):
                continue
            assert name not in self._straitMethodDict
            self._straitMethodDict[name] = obj

    def _batchCall(self, methodName, *kargs, **kwargs):
        assert methodName.startswith("_")
        for obj in self._straitObjList:
            if hasattr(obj, methodName):
                getattr(obj, methodName)(*kargs, **kwargs)

    def __getattr__(self, attr):
        obj = self._straitMethodDict.get(attr, None)
        if obj is not None:
            return getattr(obj, attr)
        raise AttributeError(f"'{self.__class__.__name__}' object has no attribute '{attr}'")       # ugly, from PEP-562, a string which is same as system error message


class OverlayfsDir:

    PERSIST = "persist"
    TRANSIENT = "transient"

    class MountError(Exception):
        pass

    def __init__(self, persist, work_dir, data_dir_list):
        assert os.path.isabs(work_dir)

        self._persist = persist
        if self._persist == self.PERSIST:
            self._path = work_dir
            self._upperDir = os.path.join(self._path, "upper")
            self._workDir = os.path.join(self._path, "work")
            Util.forceMakeDir(self._path)
            Util.forceMakeDir(self._upperDir)
            Util.forceMakeDir(self._workDir)
        elif self._persist == self.TRANSIENT:
            self._path = work_dir
            self._upperDir = os.path.join(self._path, "upper")
            self._workDir = os.path.join(self._path, "work")
            Util.forceMakeEmptyDir(self._path)
            os.mkdir(self._upperDir)
            os.mkdir(self._workDir)
        else:
            assert False

        prop = "lowerdir=%s,upperdir=%s,workdir=%s" % (":".join(data_dir_list), self._upperDir, self._workDir)
        try:
            subprocess.check_call(["fuse-overlayfs", "-o", prop, self._path])
        except subprocess.CalledProcessError as e:
            raise self.MountError(e)

    @property
    def path(self):
        return self._path

    def dispose(self):
        try:
            subprocess.check_call(["umount", self._path])
        except subprocess.CalledProcessError:
            assert False


class CopiedAndSymlinkedDir:

    class SourceError(Exception):
        pass

    def __init__(self, work_dir, src_dir_list):
        self._path = work_dir
        self._srcDirList = src_dir_list

        Util.forceMakeDir(self._path)

        self._fnSet = set()

    @property
    def path(self):
        return self._path

    def exists(self, rel_path):
        return os.path.exists(os.path.join(self._path, rel_path))

    def symlink(self, rel_path, recursive=False, exclude=[]):
        oldLen = len(self._fnSet)

        for srcDir in self._srcDirList:
            fullPath = os.path.join(srcDir, rel_path)
            if not os.path.exists(fullPath):
                continue

            if not recursive or os.path.isfile(fullPath):
                assert rel_path != "" and len(exclude) == 0

                if not self._checkSrc(fullPath):
                    raise self.SourceError("unsupported type for source \"%s\"" % (fullPath))

                Util.addNewItemToSet(self._fnSet, rel_path)
                Util.forceSymlink(fullPath, os.path.join(self._path, rel_path))

                continue

            if os.path.isdir(fullPath):
                for fullfnSrc in Util.glob(os.path.join(fullPath, "**"), recursive=True):
                    relPath = fullfnSrc[len(srcDir) + 1:]
                    if relPath in exclude:
                        continue

                    if not self._checkSrc(fullfnSrc):
                        raise self.SourceError("unsupported type for source \"%s\"" % (fullfnSrc))

                    fullfnDst = os.path.join(self._path, relPath)
                    if os.path.isfile(fullfnSrc):
                        Util.addNewItemToSet(self._fnSet, relPath)
                        Util.forceSymlink(fullfnSrc, fullfnDst)
                    else:
                        if not os.path.exists(fullfnDst):
                            Util.addNewItemToSet(self._fnSet, relPath)
                            os.mkdir(fullfnDst)
                        elif os.path.islink(fullfnDst) or not os.path.isdir(fullfnDst):
                            Util.addNewItemToSet(self._fnSet, relPath)
                            Util.forceDelete(fullfnDst)
                            os.mkdir(fullfnDst)
                        else:
                            # we don't use Util.addNewItemToSet() because different srcDir can have same directory
                            self._fnSet.add(relPath)

                continue

            assert False

        assert oldLen < len(self._fnSet)

    def copy(self, rel_path, recursive=False, exclude=[]):
        oldLen = len(self._fnSet)

        for srcDir in self._srcDirList:
            fullPath = os.path.join(srcDir, rel_path)
            if not os.path.exists(fullPath):
                continue

            if os.path.isfile(fullPath):
                assert rel_path != "" and len(exclude) == 0

                if not self._checkSrc(fullPath):
                    raise self.SourceError("unsupported type for source \"%s\"" % (fullPath))

                Util.addNewItemToSet(self._fnSet, rel_path)
                Util.forceCopyFile(fullPath, os.path.join(self._path, rel_path))

                continue

            if os.path.isdir(fullPath):
                assert recursive    # copy directory must be recursive

                for fullfnSrc in Util.glob(os.path.join(fullPath, "**"), recursive=True):
                    relPath = fullfnSrc[len(srcDir) + 1:]
                    if relPath in exclude:
                        continue

                    if not self._checkSrc(fullfnSrc):
                        raise self.SourceError("unsupported type for source \"%s\"" % (fullfnSrc))

                    fullfnDst = os.path.join(self._path, relPath)
                    if os.path.isfile(fullfnSrc):
                        Util.addNewItemToSet(self._fnSet, relPath)
                        Util.forceCopyFile(fullfnSrc, fullfnDst)
                    else:
                        if not os.path.exists(fullfnDst):
                            Util.addNewItemToSet(self._fnSet, relPath)
                            os.mkdir(fullfnDst)
                        elif os.path.islink(fullfnDst) or not os.path.isdir(fullfnDst):
                            Util.addNewItemToSet(self._fnSet, relPath)
                            Util.forceDelete(fullfnDst)
                            os.mkdir(fullfnDst)
                        else:
                            # we don't use Util.addNewItemToSet() because different srcDir can have same directory
                            self._fnSet.add(relPath)

                continue

            assert False

        assert oldLen < len(self._fnSet)

    def unmanage(self, rel_path, recursive=False):
        fullPath = os.path.join(self._path, rel_path)

        if not recursive or os.path.isfile(fullPath):
            self._fnSet.remove(rel_path)
            return

        if os.path.isdir(fullPath):
            for fullfn in Util.glob(os.path.join(fullPath, "**"), recursive=True):
                relPath = fullfn[len(self._path) + 1:]
                self._fnSet.remove(relPath)
            return

        assert False

    def backup_to(self, backup_to_dir, wildcards, exclude_wildcards=None):
        # do backup, rules:
        #   1. don't backup symlink (will there be any scenario that game creates symlink that is needed?)
        #   2. only backup directory if neccessary (will there be any scenario that empty directory is also important?)
        backupedSet = set()
        for fullfnSrc in Util.glob(os.path.join(self._path, "**"), recursive=True):
            if os.path.islink(fullfnSrc):
                continue

            relPath = fullfnSrc[len(self._path) + 1:]
            if not self._isIncludedByBackup(relPath, wildcards, exclude_wildcards):
                continue

            fullfnDst = os.path.join(backup_to_dir, relPath)
            if os.path.isfile(fullfnSrc):
                fullfnDstDir = os.path.dirname(fullfnDst)
                if not os.path.exists(fullfnDstDir):
                    os.makedirs(fullfnDstDir, exist_ok=True)
                    relDstDir = os.path.dirname(relPath)
                    while len(relDstDir) > len(relPath):
                        Util.addNewItemToSet(backupedSet, relDstDir)
                        relDstDir = os.path.dirname(relDstDir)
                    assert len(relDstDir) == len(relPath)

                Util.forceCopyFile(fullfnSrc, fullfnDst)

            Util.addNewItemToSet(backupedSet, relPath)

        # do clear (remove should-be-deleted files or directories in backup)
        for fullfn in reversed(Util.glob(os.path.join(backup_to_dir, "**"), recursive=True)):
            relPath = fullfn[len(backup_to_dir) + 1:]
            if not self._isIncludedByBackup(relPath, wildcards, exclude_wildcards):
                continue
            if relPath not in backupedSet:
                Util.forceDelete(fullfn)

    def restore_from(self, restore_from_dir, exclude=[]):
        # do restore
        for fullfnSrc in Util.glob(os.path.join(restore_from_dir, "**"), recursive=True):
            relPath = fullfnSrc[len(restore_from_dir) + 1:]
            if relPath in exclude:
                continue

            if not self._checkSrc(fullfnSrc):
                raise self.SourceError("unsupported type for backup source \"%s\"" % (fullfnSrc))

            fullfnDst = os.path.join(self._path, relPath)
            if os.path.isfile(fullfnSrc):
                Util.forceCopyFile(fullfnSrc, fullfnDst)
            else:
                Util.forceMakeDir(fullfnDst)

            Util.addNewItemToSet(self._fnSet, relPath)

        # don't do clear here, should call self.clear() after all copy/symlink is done
        pass

    def clear(self, exclude=[]):
        for fullfn in reversed(Util.glob(os.path.join(self._path, "**"), recursive=True)):
            if fullfn == self._path:
                continue

            relPath = fullfn[len(self._path) + 1:]
            if relPath in exclude or relPath in self._fnSet:
                continue

            Util.forceDelete(fullfn)

    def _checkSrc(self, fullfn):
        if os.path.islink(fullfn):
            return False
        if not (os.path.isdir(fullfn) or os.path.isfile(fullfn)):
            return False
        return True

    def _isIncludedByBackup(self, rel_path, wildcards, exclude_wildcards):
        if exclude_wildcards is not None:
            if any([fnmatch.fnmatch(rel_path, x) for x in exclude_wildcards]):
                return False
        return any([fnmatch.fnmatch(rel_path, x) for x in wildcards])


class IniFile:

    def __init__(self, filepath):
        self._path = filepath

        self._valueTypeDict = {}
        self._fallbackDict = {}

        self._obj = configparser.ConfigParser()
        if os.path.exists(filepath):
            self._obj.read(filepath)

        self._changed = False

    @property
    def filepath(self):
        return self._path

    def set_value_type(self, section, option, value_type):
        assert (section, option) not in self._valueTypeDict
        self._valueTypeDict[(section, option)] = value_type

    def set_value_type_and_fallback(self, section, option, value_type, fallback):
        if True:
            self.set_value_type(section, option, value_type)
        if True:
            assert (section, option) not in self._fallbackDict
            self._fallbackDict[(section, option)] = fallback

    def get_value_type(self, section, option):
        return self._valueTypeDict[(section, option)]

    def get_fallback(self, section, option):
        return self._fallbackDict[(section, option)]

    def has_option(self, section, option):
        if (section, option) in self._fallbackDict:
            return True
        else:
            return self._obj.has_option(section, option)

    def get_option(self, section, option, value_type, fallback=None):
        if value_type is not None:
            if (section, option) in self._valueTypeDict:
                assert value_type == self._valueTypeDict[(section, option)]
        else:
            value_type = self._valueTypeDict[(section, option)]

        if fallback is not None:
            assert (section, option) not in self._fallbackDict
        else:
            fallback = self._fallbackDict.get((section, option), None)

        try:
            if value_type == str:
                return self._obj.get(section, option, fallback=fallback)
            elif value_type == bool:
                return self._obj.getboolean(section, option, fallback=fallback)
            elif value_type == int:
                return self._obj.getint(section, option, fallback=fallback)
            elif value_type == float:
                return self._obj.getfloat(section, option, fallback=fallback)
            else:
                assert False
        except configparser.NoSectionError:
            if fallback is not None:
                return fallback
            raise

    def set_option(self, section, option, value_type, value):
        if value_type is not None:
            if (section, option) in self._valueTypeDict:
                assert value_type == self._valueTypeDict[(section, option)]
        else:
            value_type = self._valueTypeDict[(section, option)]

        # auto add section
        if not self._obj.has_section(section):
            self._obj.add_section(section)

        if value_type == str:
            self._obj.set(section, option, value)
        elif value_type == bool:
            self._obj.set(section, option, "true" if value else "false")
        elif value_type == int:
            self._obj.set(section, option, str(value))
        elif value_type == float:
            self._obj.set(section, option, str(value))
        else:
            assert False

        self._changed = True                                # FIXME

    def remove_option(self, section, option):
        if not self._obj.has_section(section):
            return

        self._obj.remove_option(section, option)
        if len(self._obj.options(section)) == 0:        # auto remove empty section
            self._obj.remove_section(section)

        self._changed = True                                # FIXME

    def write(self):
        if not self._changed:
            return
        with open(self._path, 'w') as f:
            self._obj.write(f)

    def reset(self):
        self._obj = configparser.ConfigParser()


class TempChdir:

    def __init__(self, dirname):
        self.olddir = os.getcwd()
        os.chdir(dirname)

    def __enter__(self):
        return self

    def __exit__(self, type, value, traceback):
        os.chdir(self.olddir)
